Skip to content

Release: merge development into beta - #4580

Open
github-actions[bot] wants to merge 311 commits into
betafrom
development
Open

github-actions[bot] wants to merge 311 commits into
betafrom
development

Conversation

@github-actions

@github-actions github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Automated PR to sync development changes to beta for beta release.

Merging this PR will trigger the beta release workflow.

Reminder: Add a major, minor, or patch label to this PR to control the version bump. Default is patch.

Changelist

Snapshot of development at 8e7bbd8 (16 Oct 2026) against beta at b996608: 46 PRs, 13 more bundled inside them. The chore(sync) PR that carries beta back into development is left out. PRs merged to development after this snapshot are not listed yet.

Mostly the case, draft and forms engine work (decisions 179 to 229), example sets and RBAC refinements, plus the migration-pack editor (#4602) that stackiq's CMDB import uses to show and edit its mapping packs.

Features (21)

Fixes (14)

Trains (bundled PRs) (2)

Dependencies and security (7)

Maintenance (CI, gates, specs, docs) (2)

…ver rate bands

A quantity accrues over ordered rate bands, optionally capped (ADR-118 C16):
the shape of a stepped daily penalty, tiered pricing or a bracket table, so
it is configuration for each of them. The validator now treats a lit operand
as data, so a literal rate schedule is no longer refused as malformed.
metadata.completeVia = {app, action} makes the portal seam refuse a direct
completion with 409 complete-via-app, naming the action, after the party
check and before anything is stored. Only the named app completes the task
in-process (PortalTaskService::complete(viaApp: ...)). A portal reads the
same key off the task row and opens the app's action instead of the upload
form, so an app's own checks (a frozen checklist, a signature, a payment)
cannot be skipped by completing the task through the seam.

Change a-portal-task-names-the-app-that-completes-it.
…, failing closed

A property can cite rows of another schema by a key property (referenceSchema
+ referenceKey, e.g. a code) and refuse their delete with onDelete RESTRICT.
An unreadable citation blocks the delete as unverifiable; a citing table that
does not exist yet counts as no citation. The property validator accepts the
two keys and refuses referenceKey with any other action. uuid references are
unchanged. Generic reference guard, capability C25 of hydra's
procedures-are-configuration (decision 182).
…them by key

Decision 186: a reusable decision table lives in OpenRegister as a generic
capability (ADR-118 C15). A core decision-tables register, refused at save
when the evaluator cannot run a table or its key is taken; DecisionTableStore
resolves a table by key or uuid and evaluates it for apps; the decision-table
flow step takes tableRef as an alternative to an inline table.
rubenvdlinde and others added 14 commits October 15, 2026 19:12
…ly as a mock

A fresh instance refused the zuiddrecht set: brp and kvk are mock-only
descriptors, and the real-configuration re-import correctly refuses a mock.
A requires entry now names the descriptor it wants: a slug for the real
one, {"register": "<slug>", "mock": true} for the mock, imported through
reimportMock() under openregister.mock and recorded for remove as before.
A plain slug never falls back to the mock. zuiddrecht declares brp and kvk
as mock requirements.
…erty (#4656)

ObjectTreeService walks descendants, ancestors or both along the parent
property a schema already declares (x-openregister-hierarchy.parent, else
x-openregister-party.parentProperty), one query per level, with levels,
cycle protection, the declared depth, the requested depth and the node
cap, and the caller's read access on every node. Exposed as
GET /api/objects/{register}/{schema}/{id}/tree. Decision 250.
… and nothing else (#4657)

BrpPersonProvider::countResidentsAtAddressObject() asks Haal Centraal
ZoekMetAdresseerbaarObjectIdentificatie with one field and answers
{ total, meta }. It asks only when the brp-haalcentraal source carries
configuration.residentCount: true, and the call reaches the connector with
withholdBodies so the residents' numbers are never stored. The router gains
sourceConfiguration() and the withholdBodies call option. This is the
openregister half of portaliq identity-registered-details T07.

Assisted-by: ClaudeCode:claude-opus-5-5
…ock-register

fix(example-sets): a set may require a register OpenRegister ships only as a mock
…roperty, and a config field may be a dot path

The rehearsal R2 install logged 16,038 'meta is not a valid attribute' errors:
shillinq's _meta property has a _meta column, and every magic-table reader
took each _ column for object metadata, so MagicStatisticsHandler failed the
whole row and the other readers dropped the property. MagicMetadataColumn now
decides: a property the schema declares is a property unless ObjectEntity has
that attribute. An unknown metadata column is skipped and noted once at debug.

opencatalogi's publiccode schema was rejected on every import because the
save-time check compared description.nl.longDescription with the top-level
keys; the reader walks dot paths, so the check now accepts a path whose first
segment is a property.

example-set --check counted changes into the per-schema object count (12 dossiq
cases against 10 declared); changes are now counted and printed apart.
… bulk writer asks it too

check:strict's phpmd flagged static access, a long variable name and the
bulk writer's method length; the write-side rule moves into
MagicMetadataColumn::skippedOnWrite().
fix(magic-tables,example-sets): underscore properties read as properties, dot-path config fields, check counts like install
Bumps [zod](https://github.com/colinhacks/zod) from 4.6.5 to 4.6.7.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](colinhacks/zod@v4.6.5...v4.6.7)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.63.0 to 1.64.0.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](microsoft/playwright@v1.63.0...v1.64.0)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [smalot/pdfparser](https://github.com/smalot/pdfparser) from 2.12.5 to 2.13.0.
- [Release notes](https://github.com/smalot/pdfparser/releases)
- [Commits](smalot/pdfparser@v2.12.5...v2.13.0)

---
updated-dependencies:
- dependency-name: smalot/pdfparser
  dependency-version: 2.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…#4625)

Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.68.0 to 8.71.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [vue-material-design-icons](https://github.com/robcresswell/vue-material-design-icons) from 5.3.1 to 5.4.0.
- [Release notes](https://github.com/robcresswell/vue-material-design-icons/releases)
- [Commits](robcresswell/vue-material-design-icons@5.3.1...5.4.0)

---
updated-dependencies:
- dependency-name: vue-material-design-icons
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [webonyx/graphql-php](https://github.com/webonyx/graphql-php) from 15.37.3 to 15.38.1.
- [Release notes](https://github.com/webonyx/graphql-php/releases)
- [Changelog](https://github.com/webonyx/graphql-php/blob/master/CHANGELOG.md)
- [Commits](webonyx/graphql-php@v15.37.3...v15.38.1)

---
updated-dependencies:
- dependency-name: webonyx/graphql-php
  dependency-version: 15.38.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
if ($mapping === null) {
foreach ($payload as $name => $value) {
$name = (string)$name;
if (str_starts_with($name, '_') === false && str_starts_with($name, '@') === false) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — A form payload's id picks the uuid, and a scoped draft save then writes over any draft with RBAC off

pairsOf() drops only _ and @ keys when a write has no mapping, so a submitter's id reaches $plan['object']. writeAll() passes uuid: null for a fresh submit or a new draft, and SaveObject::extractUuidAndSelfData() (SaveObject.php:3538-3539) then takes $data['id'] as the uuid. SaveObject looks that uuid up with _rbac: false and, when it exists, takes the update branch (handleObjectUpdate, SaveObject.php:3330).

For a scope-value owner (decision 195), writeAll() saves with _rbac: false. refuseSomebodyElsesDraft() returns early when RBAC is off, and DraftStatusPolicy::resolveStatus() accepts draft → draft. So a portal resident whose form has no mapping can send { "id": "<another person's draft uuid>", ... } to saveDraft() and overwrite that draft in the destination schema. The victim can be another resident or a Nextcloud user. The draft keeps its owner, so the victim later finds and submits content they did not type. The comment at lines 685-697 ("Updating or sending it was authorised by DraftFinder::own() before this write") does not hold on this path: own() is only asked when $draftId is set.

The same key affects a fresh submit under RBAC. A uuid the caller cannot read goes down checkSavePermissions()'s create branch (ObjectService.php:2275-2287). It is judged by the schema's create rule (for an anonymous visitor on a public form: the public create rule) and then stored over the hidden object. refuseSomebodyElsesDraft() closes that only for drafts.

I could not check whether the in-process callers (the portal) ever pass mapping: null. The spec explicitly allows it ("without a mapping the payload minus _ and @ keys is the object"), and FormsController::payload() forwards id too. Uncertain, so a blocker under Strict.

Impact: a form submitter (anonymous on a public form, or a portal resident through a scoped draft) can change existing objects of the destination schema that they cannot read, including other people's drafts.

Suggested fix: in pairsOf(), also drop id and uuid when no mapping is given, so a submit never chooses its uuid. The only way to name an existing object is then $draftId, which goes through DraftFinder::own(). As a backstop, in writeAll() refuse when $uuid === null and the object still carries an id.

Done when:

  • a new FormSubmitServiceTest case: saveDraft() with a scoped owner, mapping: null and payload id = another owner's draft uuid either creates a new object with a fresh uuid or is refused, and the other draft's data is unchanged. The test goes red with the id filter removed
  • the same for submit() with an anonymous subject and the id of an existing object

Reachability: a scope-value owner is only created through DraftOwner::portalSubject() / scoped() by an in-process caller; no caller in this repository creates one, so the write path is reached through the portal app's use of saveDraft(). Reported as a blocker under Strict because the payload id is not stripped on a write that runs with RBAC off.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked as #4664: filed rather than fixed here because the fix lands through development and this release PR is re-cut after every merge.

// A draft is removed for good, with its files: a tombstone of
// unsent input serves nobody, and on an archival schema the
// purge would refuse it for ever (decisions 180 and 216).
if ($objectToDelete->isDraft() === true) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — Bulk delete ignores every draft rule

The draft delete rules live only in deleteObject(): the lookup under the read RBAC (which hides somebody else's draft), isDeletableDraft() for archival schemas, and permanent = true here. deleteObjects(), behind POST /api/bulk/{register}/{schema}/delete (BulkController::delete, @NoAdminRequired), has none of them.

Contract branch (spec draft-owner-activation-and-lifetime) Single delete Bulk delete
Only somebody who can read a draft may change it ✓ RBAC lookup hides it → 404 ✗ filterUuidsForPermissions() → hasPermission(delete, objectOwner), which has no draft rule
A draft's delete is permanent, with its files ✓ line 3190 ✗ deleteHandler->deleteObject() without permanent, so a soft-deleted tombstone keeps the unsent input
One draft.removed audit entry without content ✓ written in the permanent branch of DeleteObject::delete() ✗ not permanent, so no entry
A draft is deletable on an archival schema ✓ isDeletableDraft() ✗ rejectIfArchivalImmutable() refuses it, so the owner cannot bulk-discard their own draft there

So any user with delete rights in a schema can remove another user's draft by uuid, and the removed draft's content stays in the trash.

Sibling of the open thread at DraftStatusPolicy.php:295 (bulk save over somebody else's draft). That thread covers the bulk save path, not the bulk delete path. The root cause is the same: PermissionHandler::hasPermission() has no draft rule. Putting keptFrom() into hasPermission() when an object is passed would close both threads and f3.

Suggested fix: in deleteObjects(), for each pre-resolved row that isDraft(): skip it (reason OBJECT_NOT_PERMITTED, the same answer as an unreadable row) when $_rbac is on and DraftStatusPolicy::keptFrom() is true; otherwise pass permanent: true and skip the archival gate, as deleteObject() does.

Done when:

  • a deleteObjects() test: user B bulk-deleting user A's draft gets it in skipped_uuids and the draft still exists; A bulk-deleting it removes it permanently and writes one draft.removed entry
  • the same test goes red with the new branch removed

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked as #4665: filed rather than fixed here because the fix lands through development and this release PR is re-cut after every merge.

@@ -91,7 +91,7 @@ class ReadableAuditTrailLister {
*
* @var string[]
*/
private const WITHHELD_FIELDS = ['session', 'request', 'ipAddress'];
public const WITHHELD_FIELDS = ['session', 'request', 'ipAddress'];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocker — /api/audit-trails/readable shows other users' draft contents

A draft's saves write ordinary create/update audit rows, and their changed column holds every property's old and new value (AuditTrailMapper.php:878-884). GET /api/audit-trails/readable (@NoAdminRequired) decides per row through mayRead() → PermissionHandler::hasPermission(action: 'read', objectOwner, object). hasPermission() has no draft rule (the draft rule lives only in the SQL predicate, MagicRbacHandler::reachableRowSqlFor()). Any user with read rights on the schema therefore reads, in this list, what another user typed into a draft that the object API hides from them ("a draft answers to its owner only", decision 180). WITHHELD_FIELDS removes session, request and ipAddress but keeps changed.

Sibling of the open thread at DraftStatusPolicy.php:295, which has the same root cause (hasPermission() has no draft rule) but is about a different path.

Impact: draft content (possibly personal data a resident has not yet decided to send) is readable by colleagues while the draft lives.

Suggested fix: add the draft rule to PermissionHandler::hasPermission() when an object is passed: refuse read (and every other action) when (new DraftStatusPolicy())->keptFrom($object, $schema, $userId) is true. That one change also closes f2 and the bulk-save thread.

Done when:

  • a ReadableAuditTrailListerTest case: user B, with read rights on the schema, does not get the audit rows of user A's draft. It goes red without the draft rule
  • user A still gets them

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tracked as #4666: filed rather than fixed here because the fix lands through development and this release PR is re-cut after every merge.


$receivedAt = DateTimeImmutable::createFromMutable($this->time->getDateTime());
// Creation side effects run now, once (decision 227): the created event, then the receipt.
(new ObjectEventGate(events: $this->events))->leftDraft(object: $created[0]['object'], receivedAt: $receivedAt);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern — Two concurrent leave-draft requests both announce the creation

The spec (object-events) says a listener on ObjectCreatedEvent "SHALL receive every object exactly once". promoteDraft() and DraftActivation::activate() (line 142) first check that the object is still a draft (DraftFinder::own() / ownDraft(), a plain read), then save, then call leftDraft() without asking whether the save left draft. Two requests on one draft, such as a double click or a client retry (with no Idempotency-Key or with different keys), can both pass the check before either commits. The second save then loads the now-active row, and DraftStatusPolicy::resolveStatus() returns early at line 114 because active === active, so it raises no refusal. The save goes through as an ordinary update: ObjectEventGate::updated() sends ObjectUpdatedEvent, and leftDraft() sends ObjectCreatedEvent and ObjectActivatedEvent a second time. The result is duplicated webhooks, flows, created notification rules and receipts. Nothing locks the draft, and the save has no version check.

Suggested fix: let the save report whether it used the promotion allowance, for example DraftStatusPolicy::consumePromotion(uuid): bool, or check isset(self::$promotions[$uuid]) after the write. Call leftDraft() only when the allowance was used, and otherwise answer as for a draft that is gone (404/409). Better still, refuse the write in resolveStatus() when an allowance exists for a uuid that is no longer a draft.

Done when:

  • a FormSubmitServiceTest and a DraftActivationTest case where saveObject returns an object whose stored status was already active (the allowance was not used): no ObjectCreatedEvent is dispatched. Both go red with the check removed

*/
private function expiredIn(Register $register, Schema $schema, DateTimeImmutable $cutoff): array {
$expired = [];
for ($page = 0; $page < self::MAX_PAGES; $page++) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Concern — Drafts beyond the 5,000-read cap are neither expired nor counted as deferred

The spec says: "at most 5,000 drafts read per register and schema. What is left SHALL be counted as deferred and SHALL wait for the next run." expiredIn() reads every draft (_drafts=true), with no date filter, in PAGE_SIZE pages up to MAX_PAGES, and filters by age in PHP. When a schema holds more than 5,000 drafts, the expired ones after the 5,000th row are never read. They are not counted as expired or deferred, so the log and occ openregister:drafts:expired report less than is really there. Whether a later run ever reaches them depends on the list's default sort. With a stable order, the same first 5,000 rows are read on every run, so expired drafts further down are never removed.

Suggested fix: filter in the query (@self[updated] before the cutoff, with a fallback to created), so the 5,000 rows read are 5,000 expired drafts. When the last page is full, count what remains as deferred (or report "more than N").

Done when:

  • a DraftExpiryTest case with an objects fake that returns 5,000 fresh drafts followed by expired ones: the expired ones are removed, or counted as deferred, and never missing from the counts

// A reference by key only refuses: the other actions rewrite or
// delete by uuid and would act on the wrong value.
$referenceKey = ($property['referenceKey'] ?? ($property['items']['referenceKey'] ?? null));
if ($referenceKey !== null && strtoupper((string)$property['onDelete']) !== 'RESTRICT') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Minor — Validator and service disagree on referenceKey: uuid

ReferentialIntegrityService::extractReferenceKey() treats referenceKey empty or uuid as a plain uuid reference (all onDelete actions apply). The validator refuses any non-null referenceKey unless onDelete is RESTRICT, so referenceKey: uuid with CASCADE is refused at save although the service would honour it. Use the same normalisation in both (e.g. a shared static ReferenceKey::of($property)).

Comment thread lib/Db/TaskMapper.php
* @spec openspec/changes/a-task-may-wait-on-another-task/specs/flow-tasks/spec.md#requirement-a-task-may-wait-on-another-task-and-waits-out-of-sight
*/
private function applyBlockedExclusion(IQueryBuilder $qb, TaskInboxCriteria $criteria): void {
if ($criteria->includeBlocked === true || $criteria->objectUuid !== null || $criteria->runUuid !== null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A blocked portal task is still listed and completable in the portal

#4595 (a-task-may-wait-on-another-task) says an inbox read SHALL leave out every blocked task, in its results and its total. The exclusion lives in applyBlockedExclusion(), which only applyInboxPredicates() calls. An external (portal) task is excluded from every Nextcloud inbox (applyExternalExclusion()), so the portal list is the only inbox it has. That list is PortalTaskService::listForSubject() (extended by #4569) over TaskMapper::findOpenExternalForParty() / countOpenExternalForParty(), and applyPartyPredicates() filters on assignee, performer type and is_terminal only. Composed:

  1. A portal task created with blockedBy shows up in the citizen's task list and count the moment it is created, not when its blocker closes. The released re-announcement from TaskBlockerReleaseListener then announces a task the party has been looking at all along.
  2. PortalTaskService::complete() → TaskService::complete() never reads blockedBy (the only blocker check in TaskService is refuseHandWrittenState() on create), so the party can complete the task the case meant to hold back.
  3. Each row goes through TaskInboxService::row() without openBlockers (PortalTaskService.php:137), so every row runs its own blocker query. design.md D-3 says "No query per row". inbox() batches; the portal list does not.

Neither PR saw the other: #4569 merged on 11 Oct, #4595 on 12 Oct.

* @spec openspec/changes/draft-creation-side-effects/specs/object-events/spec.md#requirement-creation-side-effects-must-be-held-for-a-draft-and-run-once-when-it-leaves-draft
*/
public function created(ObjectEntity $object): void {
if ($object->isDraft() === true) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Personal data in a draft cannot be found by a data-subject request

Three PRs combine here. None of them covers this alone:

The data-subject machinery finds a person's objects only through that index: DataSubjectRequestService::findSubjectObjects() → discover() → matchEntities() reads openregister_entities joined to openregister_entity_relations. Erasure preview and export use the same path (ErasurePreviewService::preview() line 128). A draft is never in that index, so access and erasure requests miss it. On a schema with draftLifetimeDays: false that lasts indefinitely. Even when the entity index does hit a draft, loadObject() reads it with _rbac: true, and the draft predicate hides it from anyone but its owner and administrators.

Removing a draft does not fully remove its data either. Every save of a draft writes a full create/update audit row with its values (SaveObject lines 4018-4027 and 6352). The removal spec keeps those rows on purpose ("The entries the draft's own saves wrote before the removal are not changed"), but the delete requirement promises "no tombstone of unsent input is kept" (openspec/changes/draft-owner-activation-and-lifetime/specs/form-destination/spec.md:189). Both statements cannot be true.

The draft-creation-side-effects proposal lists text extraction as deliberately held, with no mention of GDPR. I did not check whether a DSAR export has a second, non-index discovery path. Grep found none (findSubjectObjects is the entry point used by erasure).


$blockers = [];
foreach ($referencing as $refObj) {
if ($refObj->getDeleted() !== null && empty($refObj->getDeleted()) === false) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Another user's hidden draft blocks a RESTRICT delete

#4570 adds RESTRICT by key: keyReferenceBlockers() → findReferencingByKey() → findReferencingInMagicTable(). The $ref RESTRICT path that already existed reaches the same raw SQL. That SQL filters _deleted IS NULL and nothing else, and the loop here skips only deleted rows. #4566 adds drafts: a draft is visible to its owner only and expires after 30 days. A draft holds whatever a half-filled form cited, such as a code from a code list.

Together: user A's draft that cites code X stops user B from deleting X. B gets a 409 whose blockers[].objectUuid names an object B cannot open, list or resolve. B can only wait until A sends the draft or it expires. A pending draft can also block a planned deletion. The draft spec calls a draft "not yet a record: it holds unsent input", and the delete path then counts it as a live referrer. CASCADE/SET_NULL on uuid references reach into other users' drafts in the same way.

Pick one rule for drafts in referential integrity and apply it to both lookups (uuid and key). Either drafts do not block (they cannot be seen and will expire), or they block without revealing their uuid to a caller who cannot read them.

Comment thread CHANGELOG.md
- **Word-family text extraction now captures all content and supports legacy `.doc` and `.odt`.** `TextExtractionService::extractWord()` was rewritten around a recursive PhpWord element walker (`walkWordElements`, depth-capped at `MAX_WORD_DEPTH = 50`) that descends tables (rows → cells, including nested tables and in-cell text-runs / list items), section headers and footers, and document-level footnotes/endnotes — content the previous 2-level traversal silently dropped (notably entire tables, because a PhpWord `Table` exposes `getRows()`, not `getElements()`, so the old loop skipped it). The PhpWord reader is now selected from the file MIME / extension (`resolveWordReader`): `.docx` → `Word2007`, legacy `.doc` → `MsDoc`, `.odt` → `ODText` — previously the default `Word2007` reader was always used, so `.doc` and `.odt` failed to load even when routed. `application/vnd.oasis.opendocument.text` is added to the Word routing in `isWordDocument()`. (`text-extraction-word-completeness`)

### Behaviour changes
- **A draft no longer fires `ObjectCreatedEvent`, `ObjectUpdatedEvent` or `ObjectDeletedEvent` (`draft-creation-side-effects`, decision 227).** A draft is an object in status `draft`. Flows, webhooks, activity, push, metrics and notifications no longer run on a draft's saves or its delete. They run once, when the draft leaves draft: `ObjectCreatedEvent` is dispatched then, followed by `ObjectActivatedEvent`. A listener on `ObjectCreatedEvent` needs no status check. Do creation work on `ObjectCreatedEvent` only. A listener that does it on `ObjectActivatedEvent` too runs twice. Objects that never were a draft behave as before.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 CHANGELOG omits this release's upgrade-visible behaviour changes

The release adds three CHANGELOG bullets, all about drafts. These changes ship to beta with no entry:

git grep -n -i 'roles block\|frozen\|archived object\|personal settings' CHANGELOG.md returns nothing.

@rjzondervan rjzondervan left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST_CHANGES (Strict)

Not ready for beta. Five blockers sit in code no second person reviewed, three of them in the draft model: the draft rule lives in the SQL read predicate only, and PermissionHandler::hasPermission() has none, so bulk delete, the readable audit trail and (on Wilco's open thread) bulk save all reach other users' drafts. Adding the draft rule to hasPermission() when an object is passed would close all three. Two more blockers landed after Wilco's review (#4656, #4661). Under Strict every 🟡 below blocks as well.

🔴 Blockers (5)

🟡 Concerns (35)

🟢 Minor (16)

✅ Verified clean

  • BRP resident count (#4657): answers, logs and the call log carry a number only, no name, BSN or address id; there is no HTTP route in OpenRegister for it.
  • Migration-pack endpoints are admin-gated; example sets are CLI-only; pack and set loading realpath-checks fragment files (no path traversal).
  • The five migrations in this release are guarded, none returns null, and info.xml is bumped over beta; no double ObjectCreatedEvent on draft promotion.

Tracking issues

  • #4664 — A form payload's id picks the uuid, and a scoped draft save then writes over any draft with RBAC off
  • #4665 — Bulk delete ignores every draft rule: others' drafts deleted, soft-deleted, no draft.removed
  • #4666 — /api/audit-trails/readable shows other users' draft contents
  • #4667 — Tree fields returns writeOnly, property-restricted and encrypted values raw
  • #4668 — Bulk writer lets a _-named schema property overwrite metadata columns

Scope decision: full, provenance-driven review of the whole release, in five slices (forms and drafts; decisions, cases, flow, RBAC and audit; example sets, migration packs and import; database, relations, BRP and files; a cross-PR integration pass). 47 merged PRs, 0 direct commits: none had an approving review by someone other than its author, so all code was read at full depth. Wilco's 31 open threads were not repeated; siblings at other sites are marked as such.
Not read: about 560 files, mostly l10n/, tests/, openspec/ and src/ outside the migration-pack editor; translation completeness, lint, types and the unit suite are left to the green quality checks.
CI: every required check is green on 8e7bbd8; deploy / Deploy Documentation is red (not required) and E2E (Playwright) was skipped.

🤖 Generated with Claude Code

`?dunning.currentStage=2` (and `['dunning.currentStage' => 2]` from PHP)
answered an empty page: the request layer rebuilds the mangled key into
`['dunning' => ['currentStage' => 2]]`, which applyObjectFilters read as a
list of UUIDs, and a dotted key was an unknown property that added `1 = 0`.

NestedPathFilter takes the filters on a path inside an object-typed property
out of the filter map, in both spellings and at any depth, and writes their
SQL against the JSON column (`#>>` on PostgreSQL, JSON_EXTRACT on MariaDB and
MySQL): equality, a list, in, notIn, ne, isnull, like, and gt/gte/lt/lte,
numeric when the operand is a number. The QueryBuilder path and the raw UNION
path both use it. A segment that is not a plain name matches nothing; an
encrypted object property is refused as its column filter is. A filter on the
object property itself keeps its meaning.

Assisted-by: ClaudeCode:claude-opus-5-5

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants